옥텟의 상위 4비트가 0xB 이면 반 옥텟 IE 다 (표의 전체 IEI 중에 상위 4비트가 0xB 인 것은 없다).
그 외에는 옥텟을 IEI 로 보고 g_iei_tbl 에서 형식을 찾는다.
TV 는 고정 길이를 읽고, TLV 는 L 을 읽는다.
표에 없는 IEI 는 TLV 로 가정하고 IE_RAW arm 에 원문을 보관한다 (전방 호환).
ie_decode 는 먼저 memset(out, 0) 하고, 길이가 표의 [len_min, len_max] 밖이면 RET_BAD_FORMAT.
문자열 arm 은 널 종단을 붙인다.
getter 로만 읽는다. 태그가 다르면 RET_INVALID_ARG.
결과 ies[0] 은 항상 LV 이름이고, 이어서 IE 가 나타난 순서대로 쌓인다.
에러 코드 규칙 (C)
상황
리턴코드
인자가 NULL
RET_INVALID_ARG
입력이 비어 LV 가 없음, LV·TV·TLV 가 경계를 넘음, 길이가 표와 다름
RET_BAD_FORMAT
IE 가 16개를 넘어 담을 수 없음
RET_TOO_SMALL
모르는 IEI
RET_OK (IE_RAW)
union 의 arm 을 태그 확인 없이 읽지 말 것
ie->v.u32 를 읽는데 kind 가 IE_STR 이면 문자열 바이트를 정수로 해석한다.
에러도 경고도 없이 쓰레기 값이 나온다.
읽기는 항상 ie_get_u32(const ie_t *, uint32_t *) 같은 getter 로 한다.
판단표 — 코드 전에 채운다
필드
조건
다른 것과 동시에?
패턴
표현
IE 값 (u8 / u32 / u64 / str / raw)
2. pseudo code 템플릿 — 보고 쳐서 익힌다
사용법
변형별로 아래 pseudo code 를 d5_tlv/src/*.h / *.c 에 주석만 복사한다.
코드는 주석을 보고 내가 쓴다.
다 쓰면 [ ] 를 x 로 바꾼다.
막히면 §5 모범답안의 해당 단계만 본다.
2-A. tlv.h / tlv.c — Level 1 (1회차, 전부 본다)
/* tlv.h *//* [ ] 1. IEI 상수, NAME_LEN_MAX 32, UNKNOWN_NUM_MAX 8, UNKNOWN_VAL_MAX 128 *//* [ ] 2. unknown_ie_t / msg_body_t (§1-A 표) *//* [ ] 3. 선언: tlv_parse / tlv_build *//* tlv.c *//* [ ] 1. NEED(n) 매크로 — D4 와 모양은 같지만 모자라면 RET_BAD_FORMAT * (입력이 모자란 것은 '규격 위반' 이다. D4 의 NEED 는 TOO_SMALL) *//* [ ] 2. keep_unknown(out, t, v, l) * unknown_num >= 8 이거나 l > 128 이면 WRN 로그 후 return (버리되 죽지 않는다) * 아니면 슬롯에 iei / len / val 을 복사 (l == 0 이면 memcpy 안 함) *//* [ ] 3. static int parse_ies(buf, len, out, depth) ← Nested 를 위해 depth 를 받는다 * while (off < len) * NEED(2); t = buf[off++]; l = buf[off++]; ← 검사 ① * l == 0xFF 이면 NEED(2) 후 rd_u16be 로 확장 길이 * NEED(l); ← 검사 ② (제일 중요) * switch (t) * SessionId : l != 4 → BAD_FORMAT / 처음이면 저장, 아니면 WRN (첫 번째 채택) * Cause : l != 1 → BAD_FORMAT / > 63 → BAD_FORMAT * UeName : l > 32 → BAD_FORMAT / memcpy 후 name[l] = '\0' * Timestamp : l != 8 → BAD_FORMAT / 상위 4 + 하위 4 바이트 * Nested : depth >= 1 이면 BAD_FORMAT, 아니면 parse_ies(..., depth + 1) * default : keep_unknown (에러 아님) * off += l; ← 전진은 루프 끝 한 곳 *//* [ ] 4. int tlv_parse(buf, len, out) * 가드 → memset(out) → parse_ies(.., 0) * 순회가 끝난 뒤 필수 IE 검사 (SessionId, Cause 없으면 BAD_FORMAT) *//* [ ] 5. int tlv_build(in, buf, buf_len, out_len) * 가드 / *out_len = 0 * ROOM(n) 매크로: 모자라면 RET_TOO_SMALL * SessionId → Cause → UeName(has_name) → Timestamp(has_ts) → unknown[] 원문 순으로 쓴다 * 길이가 254 이하면 L 1바이트 (A 의 build 는 확장 길이를 쓰지 않는다) * *out_len = off, return RET_OK */
2-B. tlv16.c — Level 2 (2회차, 번호와 제목만 본다)
/* tlv16.c (tlv.h 의 msg_body_t 를 그대로 쓴다) *//* [ ] 1. NEED 와 keep_unknown 은 A 의 것을 재사용 (같은 헤더로 올리거나 복사) *//* [ ] 2. 순회: NEED(4) → T = rd_u16be, L = rd_u16be → NEED(L) → switch (T) → off += L *//* [ ] 3. IE 별 길이 일치 검사 (A 와 같다) *//* [ ] 4. 순회가 끝난 뒤 한 곳에서 조건 검사 * SessionId 필수 * msg_type >= 128 이면 Cause 필수, 아니면 Cause 가 왔어도 무시 (WRN, has_cause = false) */
2-C. nas.c — Level 3 (3회차, 백지)
/* 아무것도 안 본다. * §1-C 요구사항서와 §3-C 테스트만 보고 만든다. * * 막히면 세 가지만 찾아본다. * - 표 구동 디코딩 (g_iei_tbl 한 곳에서 {iei, format, kind, 길이 범위} 를 찾는다) * - union 잔재를 막는 memset 의 위치 (ie_decode 맨 앞) * - 문자열 arm 의 널 종단 (dst[l] = '\0') */
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 로 SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대 바이트를 손으로 따라가 본다.
그래도 안 되면 모범답안의 해당 단계 하나만 본다.
2-D. 순회 골격 — 이 모양이 손에 남아야 한다
while (off < len) { uint8_t t = 0; uint32_t l = 0; NEED(2); /* ① T + L 자리 */ t = buf[off++]; l = buf[off++]; if (l == 0xFF) { /* ② 확장 길이 */ NEED(2); l = rd_u16be(buf + off); off += 2; } NEED(l); /* ③ V 가 실제로 있나 — 제일 중요 */ switch (t) { case IEI_SESSION_ID: ... break; default: /* ④ 모르면 건너뛴다. 에러 아님 */ keep_unknown(out, t, buf + off, l); break; } off += l; /* ⑤ 어떤 경우든 L 만큼 전진 */}
off += l; 을 case 안에서 하지 말 것
분기마다 전진시키면 한 군데라도 빼먹는 순간 무한 루프가 된다.
전진은 루프 끝에서 한 번만.
3. 테스트 코드 — 이게 통과하면 끝
d5_tlv/src/tlv.h tlv.c 변형 A (+ 모범답안 확장은 tlv16.c 에서 재사용)d5_tlv/src/tlv16.c 변형 Bd5_tlv/src/nas.h nas.c 변형 Cd5_tlv/test/test_tlv.c 변형 A 시험 (3-0 매크로 + 3-A + main)d5_tlv/test/test_tlv16.c 변형 B 시험 (3-0 매크로 + 3-B + main)d5_tlv/test/test_nas.c 변형 C 시험 (3-0 매크로 + 3-C + main)
시험의 형태 (D1~D9 공통)
정상 한 가지, 경계 여러 가지, NULL / 실패 경로를 확인한다.
D5 는 여기에 모르는 IE 건너뛰기, 길이 거짓말, L=0, 자르기(0 ~ len-1) 가 붙는다.
3-0. 공통 매크로
#include <stdio.h>#include <stdlib.h>#include <string.h>#include "common.h"#include "log.h"log_level_t g_log_level = LOG_LV_ERR;static int g_fail = 0;#define T_EQ_INT(_got, _exp) \ do { \ long long _g = (long long)(_got), _e = (long long)(_exp); \ if (_g != _e) { g_fail++; \ printf(" X %s:%d got=%lld exp=%lld\n", \ __func__, __LINE__, _g, _e); } \ } while (0)#define T_EQ_STR(_got, _exp) \ do { \ if (strcmp((_got), (_exp)) != 0) { g_fail++; \ printf(" X %s:%d got=\"%s\" exp=\"%s\"\n", \ __func__, __LINE__, (_got), (_exp)); } \ } while (0)#define T_EQ_BYTES(_got, _exp, _n) \ do { \ if (memcmp((_got), (_exp), (_n)) != 0) { g_fail++; \ printf(" X %s:%d bytes differ\n", __func__, __LINE__); } \ } while (0)
변형 B 는 tlv.c tlv16.c test_tlv16.c, 변형 C 는 nas.c test_nas.c 를 쓴다.
4. 자주 하는 실수
실수
증상
NEED(l) 없이 memcpy(dst, buf+off, l)
원격 버퍼 오버런. TLV 파서 취약점의 99%
모르는 IEI 에서 return RET_BAD_FORMAT
상대가 버전업하면 전체 메시지 거부
l 을 uint8_t 로 받고 확장 길이 대입
0xFF 확장 시 잘린다.uint32_t 로 받는다
값 길이 검사를 안 하고 고정 필드로 복사
SessionId 가 L=2 로 오면 4바이트를 읽는다. IE 마다 길이 일치 검사
문자열 IE 를 그대로 char[] 에 복사
널 종단이 없다.L 만큼 복사 후 dst[l] = '\0'
중첩 TLV 를 재귀로만 처리
악의적 중첩으로 스택 폭발. 깊이 상한을 인자로 넘긴다
조건부 필수를 순회 도중에 판정
IE 순서가 보장되지 않아 정상 메시지를 오탐한다. 순회 후 한 곳에서
요청에 Cause 가 왔다고 반려
규격은 “무시” 다. 전방 호환 원칙(모르는 건 버리지 않는다)과 같은 방향으로 관대하게
union 을 태그 확인 없이 읽음
문자열 바이트를 정수로 해석한 쓰레기 값. getter 로만 읽기
문자열 arm 에 널 종단 없음
IE_STR 를 printf 하면 뒤 메모리까지 출력. L 만큼 복사 후 dst[l] = '\0'
표에 없는 IEI 를 디코딩 실패로 처리
상대가 버전업하면 전체 거부. IE_RAW arm 으로 받아 보존
(B) T / L 을 1바이트씩 읽음
TLV16 은 둘 다 2바이트 BE 다. rd_u16be 로 읽고 헤더 4바이트를 NEED(4) 로 확인한다
(C) 반 옥텟 IE 를 1옥텟 TV 로 처리
상위 4비트가 IEI, 하위 4비트가 값이다. octet >> 4 와 octet & 0x0F
(C) LV 의 L 을 NEED 없이 믿음
첫 요소부터 경계를 넘을 수 있다. NEED(1) → NEED(l)
(C) ie_decode 가 memset 없이 arm 을 채움
이전 IE 의 바이트가 섞여 가끔 이상한 값이 나온다
5. 모범답안 (변형 A)
다 치고 나서 열 것 — tlv_parse
#define IEI_SESSION_ID 0x01#define IEI_CAUSE 0x02#define IEI_UE_NAME 0x10#define IEI_TIMESTAMP 0x20#define IEI_NESTED 0x30#define NAME_LEN_MAX 32#define UNKNOWN_NUM_MAX 8#define UNKNOWN_VAL_MAX 128typedef struct { uint8_t iei; uint16_t len; uint8_t val[UNKNOWN_VAL_MAX];} unknown_ie_t;typedef struct { uint32_t session_id; /* M */ uint8_t cause; /* M */ bool has_session_id; /* 수신 여부 추적용 */ bool has_cause; bool has_name; char name[NAME_LEN_MAX + 1]; bool has_ts; uint64_t ts_ms; unknown_ie_t unknown[UNKNOWN_NUM_MAX]; uint8_t unknown_num;} msg_body_t;static void keep_unknown(msg_body_t *out, uint8_t t, const uint8_t *v, uint32_t l){ unknown_ie_t *u = NULL; if (out->unknown_num >= UNKNOWN_NUM_MAX || l > UNKNOWN_VAL_MAX) { LOG_WRN("drop unknown IEI=0x%02X len=%u", t, l); /* 버리되 죽지 않는다 */ return; } u = &out->unknown[out->unknown_num++]; u->iei = t; u->len = (uint16_t)l; if (l > 0) memcpy(u->val, v, l);}/* D5 의 NEED: 입력이 모자란 것은 '규격 위반' 이므로 BAD_FORMAT 이다 (D4 의 NEED 는 TOO_SMALL) */#define NEED(_n) \ do { \ if ((size_t)(_n) > buf_len - off) { \ LOG_ERR("need %zu but %zu left (%s/%d)", \ (size_t)(_n), buf_len - off, __func__, __LINE__); \ return RET_BAD_FORMAT; \ } \ } while (0)#define DUP_CHK(_has, _name) \ do { \ if (_has) { LOG_WRN("dup IE %s, keep first", _name); break; } \ } while (0)/* Nested 를 위해 depth 를 받는다. memset 과 필수 IE 검사는 바깥(tlv_parse)이 한다 */static int parse_ies(const uint8_t *buf, size_t buf_len, msg_body_t *out, int depth){ size_t off = 0; while (off < buf_len) { uint8_t t = 0; uint32_t l = 0; NEED(2); /* ① T + L */ t = buf[off++]; l = buf[off++]; if (l == 0xFF) { /* ② 확장 길이 */ NEED(2); l = rd_u16be(buf + off); off += 2; } NEED(l); /* ③ V 실재 확인 */ switch (t) { case IEI_SESSION_ID: if (l != 4) { LOG_ERR("sessionId len=%u", l); return RET_BAD_FORMAT; } if (!out->has_session_id) { out->session_id = rd_u32be(buf + off); out->has_session_id = true; } else DUP_CHK(true, "SessionId"); break; case IEI_CAUSE: if (l != 1) return RET_BAD_FORMAT; if (buf[off] > 63) { LOG_ERR("cause=%u", buf[off]); return RET_BAD_FORMAT; } if (!out->has_cause) { out->cause = buf[off]; out->has_cause = true; } break; case IEI_UE_NAME: if (l > NAME_LEN_MAX) return RET_BAD_FORMAT; if (!out->has_name) { if (l > 0) memcpy(out->name, buf + off, l); out->name[l] = '\0'; /* ★ 널 종단은 내가 붙인다 */ out->has_name = true; } break; case IEI_TIMESTAMP: if (l != 8) return RET_BAD_FORMAT; if (!out->has_ts) { out->ts_ms = ((uint64_t)rd_u32be(buf + off) << 32) | (uint64_t)rd_u32be(buf + off + 4); out->has_ts = true; } break; case IEI_NESTED: /* 깊이 1까지만: Nested 안의 Nested 는 BAD_FORMAT */ if (depth >= 1) { LOG_ERR("nested depth %d", depth + 1); return RET_BAD_FORMAT; } CHK_RET(parse_ies(buf + off, l, out, depth + 1)); break; default: /* ④ 모르는 것은 보존 후 통과 */ keep_unknown(out, t, buf + off, l); break; } off += l; /* ⑤ 전진은 여기 한 곳 */ } return RET_OK;}int tlv_parse(const uint8_t *buf, size_t buf_len, msg_body_t *out){ CHK_PTR(buf); CHK_PTR(out); memset(out, 0x00, sizeof(*out)); CHK_RET(parse_ies(buf, buf_len, out, 0)); /* 필수 IE 검사는 순회가 끝난 뒤에 (순서 보장이 없으므로) */ if (!out->has_session_id) { LOG_ERR("missing SessionId"); return RET_BAD_FORMAT; } if (!out->has_cause) { LOG_ERR("missing Cause"); return RET_BAD_FORMAT; } return RET_OK;}
눈여겨볼 점 4가지
필수 IE 검사는 루프 밖에서 한다.
순서가 보장되지 않으므로 중간에 판단할 수 없다.
IE 마다 길이 일치 검사(l != 4)를 먼저 한다.
이게 없으면 고정 폭 읽기가 그대로 오버런이다.
문자열은 memcpy 후 내가 '\0' 을 붙인다. 송신측 널 종단을 믿지 않는다.
keep_unknown 은 버려도 RET_OK 다.
보존 실패는 서비스 중단 사유가 아니다.
tlv.h 머리 · include · tlv_build — 위 tlv_parse 와 한 파일로 묶는 나머지
위 상수·구조체(IEI_* ~ msg_body_t)는 tlv.h 에 두고, 아래를 덧붙인다.
/* tlv.h 맨 위 / 맨 아래 */#ifndef TLV_H#define TLV_H#include <stdbool.h>#include <stddef.h>#include <stdint.h>/* …상수 · unknown_ie_t · msg_body_t… */int tlv_parse(const uint8_t *buf, size_t buf_len, msg_body_t *out);int tlv_build(const msg_body_t *in, uint8_t *buf, size_t buf_len, size_t *out_len);#endif/* tlv.c 맨 위 — rd_u*be 는 D4 의 be.h */#include "common.h"#include "log.h"#include "guard.h"#include "be.h"#include "tlv.h"/* tlv.c 맨 아래 */#define ROOM(_n) \ do { \ if ((size_t)(_n) > buf_len - off) return RET_TOO_SMALL; \ } while (0)int tlv_build(const msg_body_t *in, uint8_t *buf, size_t buf_len, size_t *out_len){ size_t off = 0; int i = 0; CHK_PTR(in); CHK_PTR(buf); CHK_PTR(out_len); *out_len = 0; ROOM(2 + 4); /* SessionId */ buf[off++] = IEI_SESSION_ID; buf[off++] = 4; wr_u32be(buf + off, in->session_id); off += 4; ROOM(2 + 1); /* Cause */ buf[off++] = IEI_CAUSE; buf[off++] = 1; buf[off++] = in->cause; if (in->has_name) { size_t n = strnlen(in->name, sizeof(in->name)); ROOM(2 + n); buf[off++] = IEI_UE_NAME; buf[off++] = (uint8_t)n; memcpy(buf + off, in->name, n); off += n; } if (in->has_ts) { ROOM(2 + 8); buf[off++] = IEI_TIMESTAMP; buf[off++] = 8; wr_u32be(buf + off, (uint32_t)(in->ts_ms >> 32)); wr_u32be(buf + off + 4, (uint32_t)(in->ts_ms)); off += 8; } for (i = 0; i < in->unknown_num; i++) { /* 모르는 IE 는 원문 그대로 */ const unknown_ie_t *u = &in->unknown[i]; ROOM(2 + (size_t)u->len); /* len <= 128 이라 L 은 1바이트 */ buf[off++] = u->iei; buf[off++] = (uint8_t)u->len; if (u->len > 0) memcpy(buf + off, u->val, u->len); off += u->len; } *out_len = off; return RET_OK;}
확장 — 판단표 정답, Cause 조건, IE 테이블 구동 union
필드
조건
다른 것과 동시에?
패턴
표현
Cause
msg_type >= 128 이면 M
— (단독 C)
① 독립 C
has_cause + 순회 후 조건 검사
IE 값
IEI 가 정한다
아니오 (IE 하나에 값 하나)
② 배타
tagged union, 태그 kind 신설 (IEI → kind 는 표로)
Cause 는 union 이 아니다 — 다른 필드와 배타 관계가 없고 단지 “언제 필수인가” 의 문제다.
IE 값은 arm 이 서로 다른 타입이라 union 이 자연스럽다.
/* ── 조건부 필수: 순회가 끝난 뒤 한 곳 ─────────────────────── *//* while (off < len) { ... 순회 ... } ← 여기까지는 IE 를 담기만 한다 */if (msg_type >= 128) { /* C: 응답이면 Cause 필수 */ if (!out->has_cause) { LOG_ERR("Cause required in response"); return RET_BAD_FORMAT; }} else if (out->has_cause) { LOG_WRN("Cause ignored in request"); /* 정책: 무시 */ out->has_cause = false;}if (!out->has_session_id) return RET_BAD_FORMAT; /* M */